import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ★★ルート本体は**静的 import** すること★★(`app/api/clean-logs/__tests__/route.test.ts`
// の冒頭コメントと同じ理由 — `it()` の中で `await import()` すると最初の1本が
// モジュール読み込みを丸ごと抱え込み、フルスイートで不定期にタイムアウトする)。
import { GET as ADVICE } from "@/app/api/clean-logs/[id]/advice/route";
import { GET as AREAS } from "@/app/api/clean-logs/[id]/areas/route";
import { buildAreaCompletions, selectAreaRowsForLog } from "@/lib/clean-logs/areas";
import { CLEAN_TIMETABLE_PRECHECK } from "@/lib/config";
import { db } from "@/lib/db";
import { filterFixtureAreaCompletions } from "@/lib/db/fixture-area-completions";
import { FACILITY_SECTION_VISIBLE_WHERE } from "@/lib/db/visibility";
import { logEvent } from "@/lib/logger";
import { logApiError } from "@/lib/logger/api";
import { __setAdviceStoreForTesting, type AdviceOverrides } from "@/lib/settings/advice";
import type { JsonStore } from "@/lib/settings/json-store";
import type { JudgeAreasLogEvent } from "@/lib/logger/events";
import { capConsecutiveDays } from "@/lib/format/consecutive-days";
import { loadConsecutiveDaysMax } from "@/lib/settings/display-limits";
import type {
AreaCompletionRow,
AreaCompletionsResponse,
CleanLogAdviceResponse,
} from "@/lib/types";
/**
* ★★指示書AC 段階3 の期待値表★★(`/clean-logs/{id}/areas` / `/clean-logs/{id}/advice`)。
*
* | # | 入力 | 期待 |
* |---|---|---|
* | 1 | 存在する `{id}` | ★段階0(=全件読み + 純粋関数)と同じレスポンス★ |
* | 2 | 存在しない `{id}` | ★段階0 と同じ 404★ |
* | 3 | 同じ施設・同じ日に複数エリア | ★全エリアが返る★(`{id}` は「施設と日付の入口」。指示書Z 決定1) |
* | 4 | `filter=problem` | ★`total_count` / `overall_*` は絞る**前**の値★(指示書Z 決定8 / 決定12) |
* | 5 | 読み込み行数 | ★全件読みから数十行に減る★ |
*
* ------------------------------------------------------------------------
* ★★「段階0 と同じ」をどう固定するか★★
* ------------------------------------------------------------------------
* ★期待値を数値で書き写さないこと★ — 書き写すと、実装とテストが同時にずれたときに
* 気づけません。代わりに ★★同じデータに対する「全件版 + 純粋関数」を毎回その場で計算し、
* SQL で絞った結果と突き合わせます★★(指示書AC G-3「等価テスト」)。
* ★これが「レスポンスを1バイトも変えていない」ことの検出手段★(禁止事項14)。
*
* ------------------------------------------------------------------------
* ★★DBには接続しません★★
* ------------------------------------------------------------------------
* `@/lib/db/prisma` の `getPrisma()` をスタブに差し替え、`USE_FIXTURES` を `false` に
* 固定して ★`prismaSource` の経路★ を走らせます
* (`app/api/clean-logs/__tests__/route.test.ts` と同じ作法)。
*/
// ---------------------------------------------------------------- スタブ(DBの代わり)
interface StubTimetable {
id: bigint;
facility_id: bigint;
section_id: bigint;
parent_id: bigint;
start_at: Date;
/** ★予定の終了(結果待ちの判定用。D-173)★。既定は開始の1時間後 */
end_at: Date;
precheck: number;
/** ★`deleted_at` は省略可(省略は生きている)。削除済みの行を作るテストだけが入れる(指示書BB U1)★ */
facility: { facility_name: string; deleted_at?: Date | null };
section: {
deleted_at?: Date | null;
section_name: string;
robots: {
serial_number: string;
device: { maker_id: number; model_name: string };
}[];
};
}
/**
* `clean_task_reports` の1行(★清掃実績の取得元。指示書AQ 決定2/3★)。
* ★`clean_error` 相当の列は無い★ — 未実施は「行が無い」で表す(指示書AQ §1-1)。
* ★`clean_timetable_id` / `ended_at` は nullable★(実DDL。指示書AQ 落とし穴1/3)
*/
interface StubReport {
clean_timetable_id: bigint | null;
clean_ratio: number;
/** ★生値(意味は第10回 N-2 で受領済み。判定には使わない。指示書AQ §1-2 / D-155 項2)★ */
status: number;
clean_area: number;
task_area: number;
started_at: Date;
ended_at: Date | null;
}
interface StubErrorReport {
clean_timetable_id: bigint | null;
error_type: string;
errored_at: Date;
}
interface StubData {
timetables: StubTimetable[];
/** ★`clean_timetable_histories` の行(指示書AU 段階3b)。無ければ空★ */
historyTimetables?: StubTimetable[];
reports: StubReport[];
errorReports: StubErrorReport[];
facilities: { id: bigint; facility_name: string; deleted_at: Date | null }[];
}
/** 発行されたクエリの記録(★どんな `where` が降りたか★ / ★何行読んだか★) */
interface CallRecord {
model: string;
args: Record<string, unknown>;
rows: number;
}
let calls: CallRecord[] = [];
let data: StubData;
function sameId(a: unknown, b: unknown): boolean {
if (a === null || a === undefined || b === null || b === undefined) return false;
return String(a) === String(b);
}
/**
* Prisma の `where` のうち★本作業が使う分だけ★を再現する。
* ★`in: undefined` は例外にする★ — Prisma では「条件が消えて全件」になるため(罠1)。
*/
function matchesWhere(row: Record<string, unknown>, where: unknown): boolean {
if (!where) return true;
for (const [field, cond] of Object.entries(where as Record<string, unknown>)) {
const value = row[field];
if (cond !== null && typeof cond === "object" && !(cond instanceof Date)) {
const c = cond as Record<string, unknown>;
if ("in" in c) {
if (c.in === undefined) {
throw new Error(
`[stub] where.${field}.in が undefined です。★罠1★: Prisma では条件が消えて全件になります`
);
}
if (!(c.in as unknown[]).some((x) => sameId(x, value))) return false;
}
if (c.gte instanceof Date && (value as Date).getTime() < c.gte.getTime()) return false;
if (c.lt instanceof Date && (value as Date).getTime() >= c.lt.getTime()) return false;
// ★リレーションの `{ deleted_at: null }`(指示書BB U1)は、その行の `deleted_at`(省略は生きている)で見る★
if ("deleted_at" in c && c.deleted_at === null) {
const target =
value !== null && typeof value === "object" ? ((value as { deleted_at?: Date | null }).deleted_at ?? null) : value;
if (target !== null && target !== undefined) return false;
}
} else if (!sameId(value, cond) && value !== cond) {
// ★`bigint` と `number` をまたいで比較できるようにする★(`id` / `facility_id` は BigInt 列)
return false;
}
}
return true;
}
function project(row: Record<string, unknown>, select: unknown): Record<string, unknown> {
if (!select) return row;
const out: Record<string, unknown> = {};
for (const key of Object.keys(select as Record<string, unknown>)) out[key] = row[key];
return out;
}
function findMany(
model: string,
rows: Record<string, unknown>[],
args: Record<string, unknown> | undefined
) {
const filtered = rows.filter((row) => matchesWhere(row, args?.where));
calls.push({ model, args: args ?? {}, rows: filtered.length });
return filtered.map((row) => project(row, args?.select));
}
vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }));
// ★`api.error`(指示書BB 表8 T3)を数えるため `logApiError` を差し替える★
vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() }));
// ★`judge.areas` ログの中身を見るため `logEvent` だけ差し替える★(指示書AR 段階4)。
// ★`withRequestId` はそのまま使う★(差し替えるとルートが動かない。`app/api/clean-area` のテストと同じ作法)
vi.mock("@/lib/logger", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logger")>();
return { ...actual, logEvent: vi.fn() };
});
vi.mock("@/lib/db/prisma", () => ({
getPrisma: async () => ({
cleanTimetable: {
findMany: async (args?: Record<string, unknown>) =>
findMany("cleanTimetable", data.timetables as unknown as Record<string, unknown>[], args),
},
// ★`clean_timetable_histories`(指示書AU 段階3b)。既定は空★
cleanTimetableHistory: {
findMany: async (args?: Record<string, unknown>) =>
findMany(
"cleanTimetableHistory",
(data.historyTimetables ?? []) as unknown as Record<string, unknown>[],
args
),
},
cleanTaskReport: {
findMany: async (args?: Record<string, unknown>) =>
findMany("cleanTaskReport", data.reports as unknown as Record<string, unknown>[], args),
},
robotErrorReport: {
findMany: async (args?: Record<string, unknown>) =>
findMany("robotErrorReport", data.errorReports as unknown as Record<string, unknown>[], args),
},
facility: {
findMany: async (args?: Record<string, unknown>) =>
findMany("facility", data.facilities as unknown as Record<string, unknown>[], args),
},
}),
}));
// ---------------------------------------------------------------- 合成データ
/** DB に入っているのと同じ「壁時計値としての `Date`」(D-115。他のテストと同じヘルパー) */
function dbWallClock(jstIso: string): Date {
return new Date(jstIso.replace("+09:00", "Z"));
}
/** ★実在の店名・実在のエリア名を使わない★(`CLAUDE.md` §7 / `docs/fixtures.md`) */
const FACILITIES = [
{ id: 1, name: "ドソ・キホーテ戸塚" },
{ id: 2, name: "ドソ・キホーテ四街道" },
{ id: 3, name: "フレッシュタウソ大和店" },
];
/** ★1施設あたり3エリア★(= 同じ施設・同じ日に複数エリアが並ぶ。#3) */
const SECTIONS_PER_FACILITY = 3;
/** ★全件版が「明らかに多い」と言える量にする★(#5 の読み込み行数の比較に要る) */
const DAYS = 30;
/** 対象日(★最終日★)。全件版と単一ID版の突き合わせはこの日で行う */
const TARGET_DATE = "2026-09-30";
function dayIso(index: number): string {
// 2026-09-01 から index 日ぶん進める(DAYS=30 なので 09-01〜09-30)
const d = new Date(Date.UTC(2026, 8, 1 + index));
return d.toISOString().slice(0, 10);
}
/**
* `clean_timetables.id` の採番。★日とエリアから一意に決まる形にする★
* (テスト側で「この日のこのエリアの id」を計算できるようにするため)。
*/
function timetableId(facilityId: number, dayIndex: number, sectionIndex: number): number {
return facilityId * 100_000 + dayIndex * 10 + sectionIndex;
}
/**
* 完了率。★エリアごとに変える★ —
* section 0 → 95(◎)/ section 1 → 5(×・問題)/ section 2 → `null`(未実施・問題)
* ★#4(`filter=problem` で絞られる行と絞られない行)を両方作るため★。
*/
function ratioFor(sectionIndex: number): number | null {
if (sectionIndex === 0) return 95;
if (sectionIndex === 1) return 5;
return null;
}
function buildData(): StubData {
const timetables: StubTimetable[] = [];
const reports: StubReport[] = [];
const errorReports: StubErrorReport[] = [];
for (const facility of FACILITIES) {
for (let dayIndex = 0; dayIndex < DAYS; dayIndex++) {
for (let s = 0; s < SECTIONS_PER_FACILITY; s++) {
const id = timetableId(facility.id, dayIndex, s);
timetables.push({
id: BigInt(id),
facility_id: BigInt(facility.id),
section_id: BigInt(facility.id * 10 + s),
parent_id: BigInt(0),
// ★★壁時計 20:00 の清掃★★(`toDbJst()` の付け忘れを検出する。指示書AC C-6。
// ★ローカルDB(深夜0〜3時しかない)では検出できない時間帯★)
start_at: dbWallClock(`${dayIso(dayIndex)}T20:00:00+09:00`),
end_at: new Date(dbWallClock(`${dayIso(dayIndex)}T20:00:00+09:00`).getTime() + 60 * 60_000),
precheck: CLEAN_TIMETABLE_PRECHECK.main,
facility: { facility_name: facility.name },
section: {
section_name: `エリア${facility.id}-${s + 1}`,
robots: [
{
serial_number: `8111R5628060${facility.id}${s}`,
device: { maker_id: 1, model_name: "CC1" },
},
],
},
});
const ratio = ratioFor(s);
// ★★未実施(`ratio === null`)はレポートの行を作らない★★(指示書AQ §1-1。
// 旧取得元の「`clean_error = 1` の行」に当たるものは `clean_task_reports` に無い)
if (ratio === null) continue;
reports.push({
clean_timetable_id: BigInt(id),
clean_ratio: ratio,
// ★生値の一例★(意味は第10回 N-2 で受領済み。指示書AQ §1-2)
status: 4,
// ★面積は完了率と独立に置く★(面積は表示用に残る。完了率の計算には使わない。
// 指示書AQ 決定4 / §1-3)
clean_area: ratio * 10,
task_area: 1_000,
started_at: dbWallClock(`${dayIso(dayIndex)}T20:00:00+09:00`),
ended_at: dbWallClock(`${dayIso(dayIndex)}T21:00:00+09:00`),
});
}
}
}
// ★事前チェック行(本清掃の子)★ — `status_1h` / `status_30m` の材料。
// ★対象日の1件にだけ付ける(`parent_id` の別クエリが走ることの確認も兼ねる)★
const targetId = timetableId(1, DAYS - 1, 0);
for (const [precheck, minutes] of [
[60, "19:00"],
[30, "19:30"],
] as const) {
timetables.push({
id: BigInt(targetId * 10 + precheck),
facility_id: BigInt(1),
section_id: BigInt(10),
parent_id: BigInt(targetId),
start_at: dbWallClock(`${TARGET_DATE}T${minutes}:00+09:00`),
end_at: new Date(dbWallClock(`${TARGET_DATE}T${minutes}:00+09:00`).getTime() + 60 * 60_000),
precheck,
facility: { facility_name: FACILITIES[0].name },
section: {
section_name: "エリア1-1",
robots: [
{ serial_number: "8111R56280601", device: { maker_id: 1, model_name: "CC1" } },
],
},
});
}
errorReports.push({
clean_timetable_id: BigInt(targetId * 10 + 60),
error_type: "EmergencyStop",
errored_at: dbWallClock(`${TARGET_DATE}T19:05:00+09:00`),
});
return {
timetables,
reports,
errorReports,
facilities: FACILITIES.map((f) => ({
id: BigInt(f.id),
facility_name: f.name,
deleted_at: null,
})),
};
}
/** ★全行が過去になる基準時刻★(`isFutureSchedule()` の除外が効かない状態にする) */
const NOW = new Date("2026-10-15T00:00:00+09:00");
/** ★対象の清掃ログ★(施設1・最終日・エリア1)。同じ施設・同じ日に3エリアある */
const TARGET_ID = timetableId(1, DAYS - 1, 0);
const ALL_RANGE = { facility_id: null, date: null } as const;
async function areas(id: number, search = ""): Promise<Response> {
return AREAS(new Request(`http://localhost/api/clean-logs/${id}/areas${search}`), {
params: Promise.resolve({ id: String(id) }),
});
}
async function advice(id: number): Promise<Response> {
return ADVICE(new Request(`http://localhost/api/clean-logs/${id}/advice`), {
params: Promise.resolve({ id: String(id) }),
});
}
/**
* ★連続日数用のクエリ★(`loadConsecutiveInputs()` が発行する2本)。
*
* ★★`select` の中身で見分けること★★ — 段階6 でこの2本にも `where` が付いたので、
* ★「`where` を持たない」では区別できなくなった★。
* ★他の `select` 付きクエリ(事前チェック行の `parent_id` / `precheck` / `id`)と
* 取り違えないように、この関数が読む列そのもので判定する★。
*/
function consecutiveCalls(): CallRecord[] {
return calls.filter((c) => {
const select = (c.args as { select?: Record<string, unknown> }).select;
if (!select) return false;
// ★予定は2表から読む(指示書AU 段階3b)ので、履歴側の連続日数用クエリも同じ扱い★
if (c.model === "cleanTimetable" || c.model === "cleanTimetableHistory") {
return select.facility_id === true && select.start_at === true;
}
if (c.model === "cleanTaskReport") return select.clean_ratio === true;
return false;
});
}
/** 連続日数用を除いた読み込み行数(★段階3 で減らしたいのはこちら★) */
function rowsReadExcludingConsecutive(): number {
return calls
.filter((c) => !consecutiveCalls().includes(c))
.reduce((sum, c) => sum + c.rows, 0);
}
beforeEach(() => {
data = buildData();
calls = [];
vi.mocked(logEvent).mockClear();
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
// ---------------------------------------------------------------- データ層
describe("db.cleanLogs({ id }) — 単一IDの取得経路(指示書AC 段階3)", () => {
it("★★#1 `id` で引いた1件が、全件版の同じ行と**完全一致**する★★(★連続日数まで★)", async () => {
const all = await db.cleanLogs({
id: null,
date_from: null,
date_to: null,
facility_ids: null,
});
const expected = all.find((l) => l.id === TARGET_ID);
expect(expected).toBeDefined();
const one = await db.cleanLogs({
id: TARGET_ID,
date_from: null,
date_to: null,
facility_ids: null,
});
expect(one).toHaveLength(1);
// ★★1バイトも変わっていないこと★★(禁止事項14)— `consecutive_problem_days` /
// `escalation_level` / `status_1h` / `status_30m` まで含めて比べる
expect(one[0]).toEqual(expected);
// ★連続日数は実際に 1 より大きい★(= 全期間を読めている。★C-2 の地雷の検出★)
expect(one[0].consecutive_problem_days).toBeGreaterThan(1);
});
it("★主クエリの `where` に `id` が降りている★(★メモリ上の `find()` に戻っていない★)", async () => {
calls = [];
await db.cleanLogs({
id: TARGET_ID,
date_from: null,
date_to: null,
facility_ids: null,
});
const main = calls.find((c) => c.model === "cleanTimetable" && "include" in c.args);
expect(main).toBeDefined();
expect((main!.args.where as Record<string, unknown>).id).toBe(BigInt(TARGET_ID));
// ★★1行しか読んでいないこと★★
expect(main!.rows).toBe(1);
});
it("★存在しない `id` → 0件★(★`id: null` に化けて全件になっていないことの検出★)", async () => {
const rows = await db.cleanLogs({
id: 999_999_999,
date_from: null,
date_to: null,
facility_ids: null,
});
expect(rows).toEqual([]);
});
it("★★単一ID取得では連続日数用のクエリに日付の下限が付かない★★(=全期間のまま。C-2 / 段階6 の申し送り)", async () => {
calls = [];
await db.cleanLogs({
id: TARGET_ID,
date_from: null,
date_to: null,
facility_ids: null,
});
// ★`clean_timetables` / `clean_timetable_histories`(指示書AU 段階3b)/ `clean_task_reports` の2本
// (予定の id で引く本と、予定外タスクを読む本。指示書BG §2-3)の4本★
expect(consecutiveCalls()).toHaveLength(4);
// ★予定外タスクの本も期間の下限が付かない★(`started_at` のキーごと無い)
const unscheduledCall = consecutiveCalls().find(
(c) => c.model === "cleanTaskReport" && (c.args as { select: Record<string, unknown> }).select.facility_id === true
);
expect((unscheduledCall?.args.where as Record<string, unknown>).started_at).toBeUndefined();
// ★履歴にも同じ引数★
expect(consecutiveCalls().find((c) => c.model === "cleanTimetableHistory")?.args).toEqual(
consecutiveCalls().find((c) => c.model === "cleanTimetable")?.args
);
// ★★対象ログの日付は引くまで分からないので、この経路だけは遡り範囲を決められない★★
// (段階6 で有界にできたのは `date_from` が来る経路。★申し送りに残してある★)
const timetableCall = consecutiveCalls().find((c) => c.model === "cleanTimetable");
expect((timetableCall?.args.where as Record<string, unknown>).start_at).toBeUndefined();
});
});
describe("db.areaCompletions({ facility_id, date }) — 施設・日付をSQLに降ろす(段階3)", () => {
it("★★SQL版 = 全件版 + `selectAreaRowsForLog()`(完全一致)★★(指示書AC G-3)", async () => {
const all = await db.areaCompletions(ALL_RANGE);
const expected = selectAreaRowsForLog(all, { facility_id: 1, date: TARGET_DATE });
const scoped = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
expect(scoped).toEqual(expected);
// ★3エリアぶん(#3)★
expect(scoped).toHaveLength(SECTIONS_PER_FACILITY);
});
it("★★`where` に施設ID・日付・本清掃が降りている★★(★壁時計値に直してあること★。C-6)", async () => {
calls = [];
await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
const main = calls.find((c) => c.model === "cleanTimetable")!;
const where = main.args.where as Record<string, unknown>;
expect(where.precheck).toBe(CLEAN_TIMETABLE_PRECHECK.main);
expect(where.facility_id).toBe(BigInt(1));
const startAt = where.start_at as { gte: Date; lt: Date };
// ★★`toDbJst()` を通した壁時計値であること★★(★生の `parseDate()` なら9時間ずれる★)
expect(startAt.gte.toISOString()).toBe(`${TARGET_DATE}T00:00:00.000Z`);
expect(startAt.lt.toISOString()).toBe("2026-10-01T00:00:00.000Z");
});
it("★★壁時計 20:00 の清掃がその日の範囲に**含まれる**★★(C-6。★ローカルDBでは検出できない★)", async () => {
const scoped = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
expect(scoped.every((r) => r.date === TARGET_DATE)).toBe(true);
// ★前日・翌日の指定では出てこない★
expect(await db.areaCompletions({ facility_id: 1, date: "2026-09-29" })).not.toEqual(scoped);
expect(await db.areaCompletions({ facility_id: 1, date: "2026-10-01" })).toEqual([]);
});
it("★レポートも本清掃の id で引き直す(全件読みに戻さない)★", async () => {
calls = [];
await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
const report = calls.find((c) => c.model === "cleanTaskReport")!;
expect(report.args.where).toBeDefined();
// ★未実施のエリア(section 2)はレポートの行が無いので1行少ない★(指示書AQ §1-1 の仕様変更。
// 旧取得元は未実施でも `clean_error = 1` の行があったので SECTIONS_PER_FACILITY だった)
expect(report.rows).toBe(SECTIONS_PER_FACILITY - 1);
});
it("★本清掃が0件ならクエリを1本も撃たない★(`chunkForInClause([])` が空を返す)", async () => {
calls = [];
const rows = await db.areaCompletions({ facility_id: 1, date: "2020-01-01" });
expect(rows).toEqual([]);
expect(calls.filter((c) => c.model === "cleanTaskReport")).toEqual([]);
});
it("★fixtures 版と同じ意味(`null` は「絞らない」)★(禁止事項6)", async () => {
const all = await db.areaCompletions(ALL_RANGE);
const scoped = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
expect(filterFixtureAreaCompletions(all, { facility_id: 1, date: TARGET_DATE })).toEqual(
scoped
);
expect(filterFixtureAreaCompletions(all, ALL_RANGE)).toHaveLength(all.length);
});
});
// ---------------------------------------------------------------- /clean-logs/{id}/areas
describe("GET /clean-logs/{id}/areas(指示書AC 段階3 期待値表)", () => {
/** ★段階0(全件読み + 純粋関数)と同じ計算★ をその場でやり直す */
async function expectedBody(
id: number,
filter: "problem" | "all"
): Promise<AreaCompletionsResponse> {
const logs = await db.cleanLogs({
id: null,
date_from: null,
date_to: null,
facility_ids: null,
});
const log = logs.find((l) => l.id === id)!;
const all: AreaCompletionRow[] = await db.areaCompletions(ALL_RANGE);
const selected = selectAreaRowsForLog(all, {
facility_id: log.facility_id,
date: log.scheduled_at.slice(0, 10),
});
return buildAreaCompletions(selected, filter);
}
it("★★#1 存在する `{id}` → 段階0 と同じレスポンス★★", async () => {
const expected = await expectedBody(TARGET_ID, "problem");
const res = await areas(TARGET_ID);
expect(res.status).toBe(200);
const body = (await res.json()) as AreaCompletionsResponse;
// ★★`items` / `overall_completion_rate` / `total_count` / `problem_count` が全一致★★
expect(body).toEqual(expected);
});
it("#2 存在しない `{id}` → 404(★挙動を変えない★)", async () => {
const res = await areas(999_999_999);
expect(res.status).toBe(404);
expect(await res.json()).toEqual({
error: {
code: "not_found",
message: "指定された清掃ログが見つかりません",
details: null,
},
});
});
it("★#2(構造)404 のときは `areaCompletions()` を引かない★", async () => {
calls = [];
await areas(999_999_999);
// ★連続日数用の `clean_task_reports` は走るが、エリア用の引き直しは無い★
// (★段階6 で連続日数用のクエリにも `where` が付いたので、そちらは除いて見る★)
expect(
calls.filter(
(c) =>
c.model === "cleanTaskReport" &&
"where" in c.args &&
!consecutiveCalls().includes(c)
)
).toEqual([]);
});
it("★★#3 同じ施設・同じ日の**全エリア**が返る★★(`{id}` はエリアではなく入口。指示書Z 決定1)", async () => {
const res = await areas(TARGET_ID, "?filter=all");
const body = (await res.json()) as AreaCompletionsResponse;
expect(body.total_count).toBe(SECTIONS_PER_FACILITY);
expect(body.items.map((i) => i.section_name).sort()).toEqual([
"エリア1-1",
"エリア1-2",
"エリア1-3",
]);
// ★自分自身(`{id}` の行)も含まれている★
expect(body.items.some((i) => i.section_id === 10)).toBe(true);
});
it("★#3 別のエリアの `{id}` で開いても同じ3エリアが返る★(入口が変わるだけ)", async () => {
const other = timetableId(1, DAYS - 1, 1);
const a = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
const b = (await (await areas(other, "?filter=all")).json()) as AreaCompletionsResponse;
expect(b).toEqual(a);
});
it("★★#4 `filter=problem` でも `total_count` / `overall_*` は絞る**前**の値★★(決定8 / 決定12)", async () => {
const all = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
const problem = (await (
await areas(TARGET_ID, "?filter=problem")
).json()) as AreaCompletionsResponse;
expect(problem.total_count).toBe(all.total_count);
expect(problem.overall_completion_rate).toBe(all.overall_completion_rate);
expect(problem.overall_grade).toBe(all.overall_grade);
expect(problem.problem_count).toBe(all.problem_count);
// ★★`items` だけが絞られる★★(◎のエリアが落ちる)
expect(problem.items.length).toBeLessThan(all.items.length);
expect(problem.items).toEqual(all.items.filter((i) => i.grade === "×"));
});
it("★★#5 読み込み行数が「全件」から数十行に減る★★", async () => {
// ★全件版が読む行数(段階0 相当)★
calls = [];
await db.cleanLogs({ id: null, date_from: null, date_to: null, facility_ids: null });
await db.areaCompletions(ALL_RANGE);
const before = calls.reduce((sum, c) => sum + c.rows, 0);
calls = [];
await areas(TARGET_ID);
const after = calls.reduce((sum, c) => sum + c.rows, 0);
// ★★連続日数用の全期間クエリ(段階6 で有界にする)を除いた行数★★ —
// ★段階3 で減らせるのはこちら★。★ここが数十行に収まること★
const scoped = rowsReadExcludingConsecutive();
expect(scoped).toBeLessThan(50);
// ★全件版より確実に少ない★(合成データは本清掃だけで
// 3施設 × 30日 × 3エリア = 270行ある)
expect(after).toBeLessThan(before);
expect(before).toBeGreaterThan(500);
});
});
// ---------------------------------------------------------------- judge.areas ログ
describe("GET /clean-logs/{id}/areas — judge.areas ログの分子・分母(指示書AR 段階4)", () => {
/** 直近の呼び出しで出た `judge.areas` を1件だけ取り出す */
function judgeAreasEvent(): JudgeAreasLogEvent {
const events = vi
.mocked(logEvent)
.mock.calls.map((c) => c[1])
.filter((p): p is JudgeAreasLogEvent => p.event === "judge.areas");
expect(events).toHaveLength(1);
return events[0];
}
it("★`raw_overall_completion_rate` が `overall_completion_rate_sum / overall_completion_rate_count` と一致する★", async () => {
const body = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
const ev = judgeAreasEvent();
expect(ev.raw_overall_completion_rate).toBe(
ev.overall_completion_rate_sum / ev.overall_completion_rate_count
);
// ★画面(レスポンス)の全体率とも一致する★
expect(ev.raw_overall_completion_rate).toBe(body.overall_completion_rate);
});
it("★未実施(完了率 null)の行は分子にも分母にも入らない★", async () => {
await areas(TARGET_ID, "?filter=all");
const ev = judgeAreasEvent();
// 対象日・施設1の3エリア: 95 / 5 / null(未実施)→ 分子 100・分母 2(★3 ではない★)
expect(ev.total_count).toBe(SECTIONS_PER_FACILITY);
expect(ev.overall_completion_rate_sum).toBe(95 + 5);
expect(ev.overall_completion_rate_count).toBe(2);
expect(ev.raw_overall_completion_rate).toBe(50);
});
it("★`filter=problem` でも分子・分母は絞る前の行で数える★(決定8 / 決定12)", async () => {
await areas(TARGET_ID, "?filter=problem");
const ev = judgeAreasEvent();
expect(ev.overall_completion_rate_sum).toBe(100);
expect(ev.overall_completion_rate_count).toBe(2);
});
it("★面積の合計(`overall_clean_area` / `overall_task_area`)はログに出さない★", async () => {
await areas(TARGET_ID);
const ev = judgeAreasEvent() as unknown as Record<string, unknown>;
expect("overall_clean_area" in ev).toBe(false);
expect("overall_task_area" in ev).toBe(false);
// ★式の名前を入れた旧名(指示書AR 段階4)も出さない★(指示書AS D8 で改名)
expect("overall_clean_ratio_sum" in ev).toBe(false);
expect("overall_clean_ratio_count" in ev).toBe(false);
});
});
// ---------------------------------------------------------------- /clean-logs/{id}/advice
describe("GET /clean-logs/{id}/advice(指示書AC 段階3)", () => {
it("★★#1 存在する `{id}` → 1件だけ引いても中身が変わらない★★", async () => {
calls = [];
const res = await advice(TARGET_ID);
expect(res.status).toBe(200);
const body = (await res.json()) as CleanLogAdviceResponse;
// ★★全件版の同じ行から作った入力と一致すること★★(★連続日数が壊れていない証拠★。C-2)
const all = await db.cleanLogs({
id: null,
date_from: null,
date_to: null,
facility_ids: null,
});
const log = all.find((l) => l.id === TARGET_ID)!;
// ★レスポンスに載るのは表示上限をかけた値★(指示書AF 段階6-2)なので、
// ★ルートと同じ手順で期待値を作る★(数字を書き写さない)
const capped = capConsecutiveDays(log.consecutive_problem_days, await loadConsecutiveDaysMax());
expect(body.consecutive_problem_days).toBe(capped.days);
expect(body.consecutive_problem_days_capped).toBe(capped.capped);
// ★★判定(L1〜L3)は上限をかける**前**の生値で行う★★(指示書AF 禁止事項9)
expect(body.escalation_level).toBe(log.escalation_level);
// ★連続日数が 1 より大きい=全期間を読めている(★C-2 の地雷の検出★)★
expect(log.consecutive_problem_days).toBeGreaterThan(1);
expect(body.advices.length).toBeGreaterThan(0);
});
it("★主クエリが1行しか読んでいない★(★全件読み + `find()` に戻っていない★)", async () => {
calls = [];
await advice(TARGET_ID);
const main = calls.find((c) => c.model === "cleanTimetable" && "include" in c.args)!;
expect((main.args.where as Record<string, unknown>).id).toBe(BigInt(TARGET_ID));
expect(main.rows).toBe(1);
// ★連続日数用の全期間クエリを除けば数十行★
expect(rowsReadExcludingConsecutive()).toBeLessThan(50);
});
it("#2 存在しない `{id}` → 404(★挙動を変えない★)", async () => {
const res = await advice(999_999_999);
expect(res.status).toBe(404);
expect(await res.json()).toEqual({
error: {
code: "not_found",
message: "指定された清掃ログが見つかりません",
details: null,
},
});
});
it("★`{id}` の形式が不正なら 422(`db` まで来ない)★", async () => {
calls = [];
const res = await ADVICE(new Request("http://localhost/api/clean-logs/1.9/advice"), {
params: Promise.resolve({ id: "1.9" }),
});
expect(res.status).toBe(422);
expect(calls).toEqual([]);
});
});
describe("db.areaCompletions / db.cleanLogs({ id }) — 予定を2表から読む(指示書AU 段階3b。第11回 P-7)", () => {
/** ★履歴だけにある、対象と同じ施設・同じ日のエリア★ */
const HISTORY_ID = 9_000_001;
function historyRow(): StubTimetable {
return {
id: BigInt(HISTORY_ID),
facility_id: BigInt(1),
section_id: BigInt(19),
parent_id: BigInt(0),
start_at: dbWallClock(`${TARGET_DATE}T20:00:00+09:00`),
end_at: new Date(dbWallClock(`${TARGET_DATE}T20:00:00+09:00`).getTime() + 60 * 60_000),
precheck: CLEAN_TIMETABLE_PRECHECK.main,
facility: { facility_name: FACILITIES[0].name },
section: {
section_name: "エリア1-9",
robots: [{ serial_number: "8111R56280619", device: { maker_id: 1, model_name: "CC1" } }],
},
};
}
it("★履歴だけの予定がエリア別完了率に出る★。2表に同じ引数が渡る", async () => {
data.historyTimetables = [historyRow()];
data.reports.push({
clean_timetable_id: BigInt(HISTORY_ID),
clean_ratio: 95,
status: 4,
clean_area: 950,
task_area: 1_000,
started_at: dbWallClock(`${TARGET_DATE}T20:00:00+09:00`),
ended_at: dbWallClock(`${TARGET_DATE}T21:00:00+09:00`),
});
calls = [];
const rows = await db.areaCompletions({ facility_id: 1, date: TARGET_DATE });
const row = rows.find((r) => r.clean_log_id === HISTORY_ID);
expect(row).toMatchObject({ section_name: "エリア1-9", completion_rate: 95 });
const main = calls.find((c) => c.model === "cleanTimetable" && "include" in c.args)!;
const history = calls.find((c) => c.model === "cleanTimetableHistory" && "include" in c.args)!;
expect(history.args).toEqual(main.args);
});
it("★履歴だけの予定を id で1件引ける★(`/clean-logs/{id}/advice` / `/{id}/areas` の入口)", async () => {
data.historyTimetables = [historyRow()];
const one = await db.cleanLogs({ id: HISTORY_ID, date_from: null, date_to: null, facility_ids: null });
expect(one.map((l) => l.id)).toEqual([HISTORY_ID]);
});
});
describe("削除済みの施設・清掃エリア(指示書BB U1・表1 V2/V3)", () => {
const DELETED = dbWallClock("2026-09-01T00:00:00+09:00");
function markDeleted(id: number, what: "facility" | "section") {
data.timetables = data.timetables.map((t) =>
Number(t.id) !== id
? t
: what === "facility"
? { ...t, facility: { ...t.facility, deleted_at: DELETED } }
: { ...t, section: { ...t.section, deleted_at: DELETED } }
);
}
it.each(["facility", "section"] as const)(
"★V2(%s が削除済み): 見えない行の `{id}` は advice / areas とも404★",
async (what) => {
markDeleted(TARGET_ID, what);
expect((await advice(TARGET_ID)).status).toBe(404);
expect((await areas(TARGET_ID)).status).toBe(404);
}
);
it("★V3: 生きている行から開いたとき、同じ施設・同じ日の削除済みの清掃エリアは items・件数・平均に入らない★", async () => {
const before = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
expect(before.total_count).toBe(3);
// エリア1-2(完了率5%)を削除済みにする
markDeleted(timetableId(1, DAYS - 1, 1), "section");
const res = await areas(TARGET_ID, "?filter=all");
expect(res.status).toBe(200);
const body = (await res.json()) as AreaCompletionsResponse;
expect(body.items.map((i) => i.section_name).sort()).toEqual(["エリア1-1", "エリア1-3"]);
expect(body.total_count).toBe(2);
// ★平均は 95 と 5 の平均(50)から、95 だけ(1-3 は未実施で分母に入らない)に変わる★
expect(body.overall_completion_rate).toBe(95);
});
it("V2・V3: `cleanLogs({ id })` と `areaCompletions()` の予定の `where` に可視性が入る", async () => {
await areas(TARGET_ID);
const mainCalls = calls.filter(
(c) => c.model === "cleanTimetable" && "include" in c.args
);
expect(mainCalls.length).toBeGreaterThanOrEqual(2);
for (const c of mainCalls) {
expect((c.args as { where: Record<string, unknown> }).where).toMatchObject(FACILITY_SECTION_VISIBLE_WHERE);
}
});
});
/**
* ★表6 A7・A8(指示書BB A3)★ — 結果待ちはデータ層が `isResultPending()` で決める(Prisma 経路)。
* 対象日(09-30)の施設1は 20:00〜21:00。エリア1-3(section 12)はレポートなし。
* ★終了+120分=23:00★ より前なら結果待ち、ちょうどなら確定した未実施(表1 P6 と同じ境界)。
*/
describe("GET /clean-logs/{id}/areas — 結果待ち(指示書BB 表6 A7・A8)", () => {
async function areaOf(sectionId: number) {
const body = (await (await areas(TARGET_ID, "?filter=all")).json()) as AreaCompletionsResponse;
return { body, item: body.items.find((i) => i.section_id === sectionId) };
}
it("A8: now=22:59(終了+120分より前)→ section 12 は result_pending: true。problem_count に数えない", async () => {
vi.setSystemTime(new Date(`${TARGET_DATE}T22:59:00+09:00`));
const { body, item } = await areaOf(12);
expect(item).toMatchObject({ result_pending: true, grade: "×", completion_rate: null });
// 問題は section 11(5%)だけ
expect(body.problem_count).toBe(1);
expect(body.total_count).toBe(3);
});
it("★A7: now=23:00(終了+120分ちょうど)→ result_pending: false(確定した未実施)★", async () => {
vi.setSystemTime(new Date(`${TARGET_DATE}T23:00:00+09:00`));
const { body, item } = await areaOf(12);
expect(item).toMatchObject({ result_pending: false, grade: "×" });
expect(body.problem_count).toBe(2);
});
it("レポートのある行は result_pending: false", async () => {
vi.setSystemTime(new Date(`${TARGET_DATE}T22:00:00+09:00`));
const { item } = await areaOf(10);
expect(item?.result_pending).toBe(false);
});
});
// ★表10 N6(指示書BB A6・U4)★ — 単一ID(`/advice`・`/areas`)の連続日数の入力は施設で絞らない(TeamWorks の索引の後)
describe("単一ID の連続日数の入力は施設で絞らない(指示書BB 表10 N6・U4)", () => {
it.each(["advice", "areas"] as const)("%s: 連続日数用の予定のクエリに `facility_id` の条件が無い", async (which) => {
calls = [];
const res = which === "advice" ? await advice(TARGET_ID) : await areas(TARGET_ID);
expect(res.status).toBe(200);
const timetableConsecutive = consecutiveCalls().filter((c) => c.model === "cleanTimetable");
expect(timetableConsecutive.length).toBeGreaterThan(0);
for (const c of timetableConsecutive) {
expect((c.args as { where: Record<string, unknown> }).where).not.toHaveProperty("facility_id");
}
});
});
// ★表8 T3(指示書BB A5・D18・D-182)★ — アドバイスの文言の読み込みが例外なら、標準の形の500と `api.error` が1件
describe("GET /clean-logs/{id}/advice — 文言の読み込みの例外(指示書BB 表8 T3)", () => {
it("★T3: `loadAdviceRules()` が例外 → 500・internal_error・「データの取得に失敗しました」・api.error が1件★", async () => {
vi.mocked(logApiError).mockClear();
const broken: JsonStore<AdviceOverrides> = {
read: async () => {
throw new Error("[stub] 読み込みに失敗");
},
write: async () => {},
};
__setAdviceStoreForTesting(broken);
try {
const res = await advice(TARGET_ID);
expect(res.status).toBe(500);
expect(await res.json()).toEqual({
error: { code: "internal_error", message: "データの取得に失敗しました", details: null },
});
expect(vi.mocked(logApiError)).toHaveBeenCalledTimes(1);
expect(vi.mocked(logApiError).mock.calls[0][0]).toMatchObject({ method: "GET", status: 500 });
} finally {
__setAdviceStoreForTesting(null);
}
});
});